iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
IT Operation

地端機房的三十天維運:開源服務封裝、GPU 節點守護與可稽核的變更管理系列 第 13 篇

Day 13|別讓節點踩爆模型!NFS 集中模型庫的單寫多讀、權限凍結與完整性治理

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260927/201418168YOptSCnOa.png

專案原始碼與完整實作:nfs-model-library
系列專案索引:onprem-ops-30days


集中模型庫的三個真實鬼故事

在 Day 1 開出的設備清單中,我規劃在 QNAP TS-464 NAS 上開一個共享資料夾,作為環境內 AI 算力 Cluster 所有運算節點共用的 NFS 集中模型庫。理想很豐滿,一份模型權重,大家掛載一起讀,省下好幾百 GB 的磁碟空間與重複下載時間。

但到了 Day 12,這個集中模型庫在實際運作中接連爆了三次雷,每一次都直指架構設計上的根本缺口:

  1. 併發寫入衝突(Day 8):實測兩台節點上的 Ollama 同時對同一個 NFS 目錄執行 pull。三輪測試裡只有第一輪碰巧有一邊成功,其餘兩輪都在拉取大型 layer 時互相把對方的 -partial 暫存檔刪除,最後留下鎖死且損壞的殘檔,導致兩邊節點雙雙卡死。
  2. 模型來源不明(Day 10):在測試 Engram 分片時,為了省下幾十 GB 的下載時間,直接從本機既有的暫存檔案拷貝進模型庫。結果過了幾天,沒有任何人記得那份檔案是哪一天、從哪個 Hugging Face repo 的哪一個 revision / commit 拉下來的,可重現性直接破功。XD
  3. 掛載狀態漂移(Day 11):盤點系統快照時意外發現,節點 A 啟用了模型庫的 automount,節點 B 卻沒有,兩台機器掛載的目錄路徑完全一致,但底層掛載機制與參數卻各走各的路。

這三起事件分別代表了地端 AI 基礎設施最常見的三大問題,也就是 併發寫入損壞、版本來源不明 與 節點掛載漂移。

今天決定不靠工程師彼此口頭約定的作法,而是定下一套硬性架構規則,並搭配一支只依賴 Python 標準函式庫的自研管理工具 mlib.py(收錄於 nfs-model-library),將這三個問題解掉。


一、架構心法:單寫多讀(SWMR)與權責劃分

Day 8 的慘痛教訓證明:多節點併發寫入同一個共享目錄不是運氣好就不會出事,而是必然會引發災難。

Ollama 或 Hugging Face CLI 本身並沒有跨主機的分散式檔案鎖機制。最乾淨、最穩健的架構解法不是在應用層到處補鎖,而是在存取架構上讓併發衝突不可能發生,也就是「單寫多讀(Single-Writer Multi-Reader, SWMR)」。

1.1 節點角色與權限矩陣

因此,把所有的模型寫入、驗證與退役權責收攏到單一節點,並在架構上定義其為圖書館員(Librarian),就是我自己很喜歡的村上春樹小說《世界末日與冷酷異境》裡面,司書的角色。硬體對應如下:

                        +---------------------------------------+
                        |           QNAP TS-464 NAS             |
                        |      (QuTS hero 6.0.2 / ZFS)          |
                        |         /srv/models 共享資料夾         |
                        +-------------------+-------------------+
                                            |
                  +-------------------------+-------------------------+
                  | (NFS Read-Write)                                  | (NFS Read-Only)
                  v                                                   v
+-----------------------------------+               +-----------------------------------+
|      主控桌機 / GB10 工作站         |               |       GPU 運算節點 / 容器節點        |
|    【圖書館員 / Librarian 節點】     |               |    (DGX 節點、ComfyUI、NAS 容器)    |
+-----------------------------------+               +-----------------------------------+
| - 唯一具備讀寫權限                  |               | - 嚴格唯讀掛載 (ro)                |
| - 執行 mlib.py add / ingest / pull|               | - 僅負責載入模型與推論運算         |
| - 權限凍結、計算校驗碼              |               | - 完全杜絕誤刪、誤蓋或殘檔寫入     |
| - 世代淘汰與 gc 清理               |               +-----------------------------------+
+-----------------------------------+
實體主機 / 節點 架構角色 掛載權限 允許操作
主控桌機 / GB10 工作站 圖書館員(Librarian) 讀寫(RW) 模型下載(add)、登錄(ingest)、Ollama 鏡像拉取(pull)、退役與清理
DGX 運算節點 推論讀者 唯讀(RO) vLLM / SGLang 模型載入與推論
Intel 工作站 推論讀者 唯讀(RO) ComfyUI 載入 Checkpoints / LoRA
NAS 本地 Ollama 容器 推論讀者 Bind Mount :ro 僅載入權重推論(Day 8 實測唯讀僅跳快取警告,運作正常)

1.2 NAS 端硬性限制:拒絕網段授權

唯讀不能依賴客戶端自律,必須由 NAS 底層的 NFS Export 規則保證。

TS-464 採用 QuTS hero 6.0.2(底層為 ZFS)。在「控制台 > 權限 > 共用資料夾 > 編輯共用資料夾權限」中選擇「NFS 主機存取」:

  • 每一台主機獨立一列 IP,明確給予權限。
  • 只有圖書館員節點(主控桌機 / GB10)設定為「讀取/寫入」,其餘一律「唯讀」。
  • 嚴禁開放整個子網段(如 192.168.1.0/24)設為讀寫,否則等於將寫入衝突的大門重新打開。

1.3 圖書館員內部的防併發設計:原子目錄鎖

即便只有圖書館員主機有寫入權限,仍可能發生兩位工程師同時 SSH 進去執行 mlib.py add 的情況。

傳統的 flock 在 NFS 上高度依賴 rpc.statd 與 lockd,在網路抖動或特定 NAS 實作下極度不可靠。因此,mlib.py 採用 POSIX 標準中最乾淨的原子操作,mkdir 原子鎖:

  1. 建立 .lock/ 目錄成功者取得鎖,目錄內記錄 PID、主機名與時間戳,若目錄已存在則代表被佔用。
  2. 鎖設定有 6 小時超時機制,避免程序異常死亡造成死鎖。
  3. 區分「下載鎖」與「操作鎖」:耗時長達數小時的模型下載不該卡住其他項目的退役或查詢。因此 .lock 只保護 Manifest 寫入與退役搬移,下載的防禦則是直接以目標 Commit 目錄的 os.mkdir 進行原子佔位,只要目錄已存在即拒絕下載,避免同版本重複拉取的資源浪費。

二、目錄規範與權限凍結機制

2.1 嚴謹的目錄樹結構

/srv/models/                      # NAS 匯出的 models 共享資料夾根目錄
├── MANIFEST.tsv                  # 只增不改 (Append-only),全庫唯一的元資料帳本
├── .lock/                        # 跨程序的原子 mkdir 鎖目錄
├── .trash/                       # 軟刪除回收桶
│   └── <YYYYMMDD>/<entry>/       # 退役項目暫存處,等待 gc 週期性清除
├── llm/                          # Hugging Face 格式模型 (Safetensors / Config)
│   └── <org>/<name>/<commit12>/  # 以 Commit Hash 前 12 碼命名,絕對不可變
├── gguf/                         # GGUF 格式量化權重檔
│   └── <org>/<name>/<commit12>/
├── comfy/                        # 專供 ComfyUI 讀取的目錄結構 (對應 Day 7)
└── ollama/                       # Ollama 專屬 blob 目錄 (僅由圖書館員執行 pull)

2.2 為什麼目錄必須以 Commit 命名,絕不能用 main?

在初版設計中,目錄直接採用 repo 的 revision 名稱(例如 main)。這在實務上立刻帶來嚴重後果,

  • 當 upstream 的 main 分支更新時,再次執行 add 會發現 main 目錄已存在而被拒絕。
  • 若強制覆蓋,舊版本的檔案校驗立刻失效,若先刪除舊目錄,那「多版本共存」與「保留最新兩代」的維運治理便蕩然無存。

因此,mlib.py add 在連線至 Hugging Face 時,會先呼叫 API 將 main 或 tag 動態解析為具體的 40 位 Commit Hash,並截取前 12 碼建立目錄。

  • 好處:版本完全不可變(Immutable),每一次更新都是一個全新的獨立目錄,舊版推論完全不受干擾。
  • 代價:推論服務(如 vLLM)的掛載路徑必須明確指定 Commit 碼。但這正是 MLOps 所追求的,更換模型權重必須視為一次嚴肅的線上變更(Day 26 變更單),而不是放任 main 分支在半夜悄悄變更。

三、Manifest 與雙層校驗碼設計

3.1 唯增帳本:MANIFEST.tsv

在模型庫根目錄維護一份 MANIFEST.tsv,所有操作只在檔尾追加(Append-only),包含 10 個標準欄位:

added_at  entry  source  revision  bytes  files  sha256_of_sums  license  added_by  note
  • 堅持使用純文字 TSV 而非 SQLite/JSON:即便在十年後、工具腳本遺失的極端情況下,任何維運人員只要用 grep、awk 或 cut 就能閱讀與復原元資料。
  • 當模型退役時,也是追加一筆 source 記為 retired 的紀錄,完整保留該項目從建立到除役的時間軸。

3.2 雙層式完整性校驗架構

+--------------------------------------------------------------------------+
| 第一層校驗:目錄內獨立校驗                                                  |
| 項目目錄/SHA256SUMS  --->  可直接使用系統標準指令:sha256sum -c SHA256SUMS  |
+--------------------------------------------------------------------------+
                                    ^
                                    | (計算整體 SHA256)
+-----------------------------------+--------------------------------------+
| 第二層校驗:中央防篡改校驗                                                 |
| MANIFEST.tsv 中的 sha256_of_sums 欄位                                    |
| 作用:杜絕「修改了模型權重,同時順手重新產生 SHA256SUMS」的隱蔽篡改狀況        |
+--------------------------------------------------------------------------+
  1. 第一層(檔案級別):每個模型目錄下都有一份標準格式的 SHA256SUMS。任何節點無需安裝任何專屬工具,原生 Linux 下跑 sha256sum -c 即可驗證權重是否損壞。
  2. 第二層(目錄級別):在中央 MANIFEST.tsv 中,記錄該目錄 SHA256SUMS 檔案本身的雜湊值(sha256_of_sums)。
    • 這是關鍵防線,如果有內部人員擅自抽換模型權重,並自作聰明在目錄下重算了一份新的 SHA256SUMS,第一層檢驗會全數通過,但 mlib.py verify 會拿它與 Manifest 比對,立刻揪出 sums-changed 違規。

3.3 Ollama 鏡像的特殊驗證邏輯

Ollama 的內部結構由其本身的 manifest 與 blob 管理,mlib.py 不會在 ollama/ 目錄內寫入校驗碼或凍結目錄。但實測發現:Ollama 只有在 pull 下載時驗證 Digest,在載入推論時完全不驗證,

🧪 破壞性實驗:
我們故意將 smollm2:135m 的權重 blob 內容竄改一個位元組。檔名雜湊與內部資料早已對不上,但 Ollama 載入時毫無警訊,依然若無其事地回覆「Hello! How can I help you today」,在推論品質上默默劣化。

為了解決這個隱患,mlib.py verify --ollama 提供深度巡檢,重算每個 blob 的雜湊並直接比對其檔名,同時主動揪出 Day 8 殘留的 -partial 垃圾暫存檔。


四、生命週期治理:世代淘汰與安全清理

Day 9 評估大型 MoE 模型(如動輒 150GB+)進駐後,模型庫年增量將達到 1~2 TB。定期的生命週期維護刻不容緩:

治理維度 規則設定 實作機制
保留版本數 同一模型保留最新 2 代 Commit mlib.py gc --keep-gens 2 自動退役過舊版本
回收緩衝期 移至 .trash/ 暫存 14 天 mlib.py gc --keep-days 14,超過天數才執行硬刪除
例外鎖定標記 Note 欄位包含 keep 關鍵字 豁免於 GC 清理,不計入 2 代名額,mlib.py ls 標示為 [K]
清理範圍隔離 僅鎖定 llm/ 與 gguf/ 絕對不動 ollama/(由 Ollama 自行管理)與 comfy/(有自屬權重流動機制)
# 建議設定於圖書館員節點(主控桌機 / GB10)的 crontab:每週一清晨發送模擬報告
0 4 * * 1 /usr/local/bin/mlib.py gc --dry-run | mail -s "[MLOps] 每週模型清理建議" ops@internal

維運原則:自動化腳本絕不直接執行 150GB 檔案的 rm -rf。一定是先退役至 .trash,或是透過變更單確認後,由工程師手動核可才執行刪除。


五、標準化掛載規則與 NFS 參數踩坑

為徹底根除 Day 11 的「掛載配置漂移」,所有運算節點統一把掛載寫入 /etc/fstab,廢除不可預測的 autofs。

5.1 讀者節點推薦配置

nas:/models  /srv/models  nfs4  ro,vers=4.1,hard,noatime,nconnect=4,rsize=1048576,_netdev  0 0

關鍵參數說明:

  • ro:讀者端從 kernel 層就擋死寫入行為。
  • vers=4.1:QNAP QuTS hero 的 NFS 服務支援 v4、v4.1 與「v4.2 (Beta)」。生產環境嚴禁使用 Beta 版,4.1 具備完整的 Session 機制與效能表現。
  • hard:強烈建議,若設為 soft,在 NAS 短暫抖動或網路塞車時,I/O 會直接回傳錯誤給載入程式,導致推論引擎只載入半套損壞的模型,hard 掛載會維持掛起重試,確保資料完整性。
  • nconnect=4:透過多個 TCP Session 壓榨 10GbE 頻寬(Day 14 將實測吞吐效能)。

NFS nconnect 的連線共用陷阱
在 Linux 核心中,針對同一個 NAS IP 位址,只有第一個掛載點的 nconnect 參數會生效!後續對同 IP 的所有掛載點都會默默共用既有的連線池。
檢驗連線數是否有達到預期的唯一標準是透過 Socket 指令檢查,而非看 mount 的輸出:
ss -tn 'dst <NAS_IP>:2049' | tail -n +2 | wc -l


六、嚴密實測,從假目錄到極限併發壓測

mlib.py 的邏輯不僅通過單元測試,更在實體環境下進行了高強度驗證。

6.1 離線與邊界情境驗證

測試情境 實測表現
初版 add 同時帶多個 --include huggingface_hub 0.36.2 只認最後一個參數,遺漏核心 safetensors。新版改為分次下載,完整抓取
竄改權重後執行 verify 精確指認 mismatch config.json,回傳結束代碼 1
竄改權重並「重新計算 SHA256SUMS」 sha256sum -c 被騙過,但 mlib.py verify 比對第二層 Manifest 雜湊立刻報出 sums-changed
Ollama 偽造壞 blob 載入 Ollama 無警訊照常載入,但 mlib.py verify --ollama 精準揪出損壞並列出殘留 -partial 檔案
重入鎖(Re-entrancy)邊界 修正初版 gc 呼叫 retire 時內部重複索鎖導致死鎖 5 分鐘的 Bug

6.2 雙台實體機器極限搶鎖壓測

我們在真實的 NFS TS-464 掛載環境下,由兩台節點以 UID 1000(非 root)同時執行併發壓力測試:

# 兩台主機同時執行 1500 次搶鎖、計數器累加與追加 Manifest
./stress_test.py --iterations 1500
  • 測試過程:雙機共累計發生約 6.9 萬次 鎖衝突重試(Backoff retry),在 Manifest 帳本中雙方寫入記錄交錯達 1,676 次。
  • 測試結果:計數器精準停在 $1500 \times 2 = 3000$,無任何資料遺失,Manifest 產出的 3,000 列事件紀錄完全無交疊損壞、無重複項、順序完全正確!

七、實戰操作指南

所有工具指令均收錄於 nfs-model-library。

7.1 在圖書館員節點(主控桌機 / GB10)上操作

# 取得工具並指定模型庫根目錄
git clone https://github.com/ivanusto/nfs-model-library && cd nfs-model-library
export MLIB_ROOT=/srv/models

# 1. 正規化下載 Hugging Face 模型 (自動解析 Commit、下載、校驗、凍結)
./mlib.py add Qwen/Qwen2.5-Coder-32B-Instruct \
  --include '*.safetensors' \
  --include '*.json' \
  --license Apache-2.0

# 2. 登錄非下載來源的既有分片 (如 Day 10 自建模型)
./mlib.py ingest llm/LibertAIDAI/DeepSeek-V4.1-Flash-REAP-256E/unknown \
  --source https://huggingface.co/LibertAIDAI/DeepSeek-V4.1-Flash-REAP-256E \
  --revision unknown \
  --note "keep: Day 10 對照組模型"

# 3. 代理拉取 Ollama 模型 (寫入中央 ollama 目錄並記錄 Manifest)
./mlib.py pull gpt-oss:20b

# 4. 全庫巡檢 (包含 Ollama blob 完整性與殘檔掃描)
./mlib.py ls
./mlib.py verify --ollama

# 5. 週維護:生命週期淘汰模擬
./mlib.py gc --dry-run

7.2 在讀者運算節點(零相依,純 Linux 原生指令)

讀者端完全不需要安裝 Python 腳本或任何額外套件:

# 1. 快速自我驗證模型完整性
cd /srv/models/llm/Qwen/Qwen2.5-Coder-32B-Instruct/<commit12>
sha256sum -c SHA256SUMS | grep -v ': OK$'
# 若無任何輸出,代表該目錄下所有權重百份之百健康

# 2. 檢視模型入庫帳本歷史
grep -v '^#' /srv/models/MANIFEST.tsv | cut -f1,2,4,8 | column -t

結語與明天預告

透過「單寫多讀」、「NFSv4 ACL 權限凍結」、「雙層 Manifest 校驗」與「Commit-based 命名規範」,我們徹底告別了併發踩踏、來源不明與節點漂移的噩夢。現在,整座 cluster 的模型儲存庫真正具備了生產級別的防禦力與可追蹤性。

但集中化架構最常面臨的質疑始終是:「大家擠在同一台 NAS 上讀模型,速度真的夠快嗎?」

Day 14,將進入儲存路徑實測,在同一套硬體環境下,針對同一個大型模型,正面對決 本地 NVMe SSD vs NFS (nconnect=4) vs iSCSI 的冷啟動載入秒數,並透過真實效能資料與 RAID 計算器,評估 NAS 儲存池究竟該選 RAID 60 還是 ZFS RAIDZ2!


參考資料


上一篇
Day 12|熱與統一記憶體守護:取樣器、自動降載與長時間負載實測
下一篇
Day 14|儲存規劃與路徑實測:RAID 60 與 RAIDZ2 試算,SSD、NFS、iSCSI 載入時間解析
系列文
地端機房的三十天維運:開源服務封裝、GPU 節點守護與可稽核的變更管理 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言